Skip to content

fix(pptx): convert soft line breaks instead of emitting a vertical tab - #2513

Open
Lukas (L4XB) wants to merge 2 commits into
microsoft:mainfrom
L4XB:fix/pptx-soft-line-breaks
Open

Lukas (L4XB) wants to merge 2 commits into
microsoft:mainfrom
L4XB:fix/pptx-soft-line-breaks

Conversation

@L4XB

@L4XB Lukas (L4XB) commented Sep 16, 2026 •

Copy link
Copy Markdown

Summary

Shift+Enter inside a PowerPoint paragraph is an <a:br/> — one paragraph, two
lines — and python-pptx reports it as \v. PptxConverter passes shape.text,
cell.text, the notes text and the chart title straight through, so that
vertical tab lands in the Markdown:

>>> MarkItDown().convert("address.pptx").markdown
'<!-- Slide number: 1 -->\nADDRESS LINE ONE\x0bADDRESS LINE TWO'

A vertical tab is a C0 control, not Markdown. A renderer drops it, so the two
lines run together (ADDRESS LINE ONEADDRESS LINE TWO), and nothing downstream
treats it as whitespace either — a splitter sees one long line and a tokenizer
sees one strange token. Address blocks, signature blocks and "name / title"
boxes are where this shows up, which is most of the text in a deck's smaller
shapes.

After:

<!-- Slide number: 1 -->
# TITLE ONE TITLE TWO
ADDRESS LINE ONE
ADDRESS LINE TWO
| HEADER A HEADER B | H2 |
| --- | --- |
| CELL LINE ONE CELL LINE TWO | C2 |

### Notes:
NOTE LINE ONE
NOTE LINE TWO

What changed

One helper, applied where the text enters: \v becomes \n. A shape's own
paragraphs already arrive joined with \n, so a soft break now reads the same
way, and the table path needs nothing special — the newline goes into the HTML
the table is built from, where it is ordinary whitespace and collapses to a
space, so the row stays one row.

A slide title is the one place a newline cannot go: an ATX heading ends at the
line break, which would leave the rest of the title as body text. Titles
therefore collapse their line breaks to spaces. That also repairs the
already-broken case of a multi-paragraph title, which produces
# First\nSecond today.

Tests

test_pptx_soft_line_breaks_are_not_vertical_tabs in
packages/markitdown/tests/test_pptx.py (the PowerPoint suite since #2579) builds a deck with a soft break
in a title, a text box, a header cell, a body cell and the speaker notes, then
asserts no \v survives and each one reads correctly. It fails against main's converter.

$ pytest packages/markitdown/tests/ -q        # after merging main
1096 passed, 12 skipped

The suite includes the reference test.pptx vector, so the title change is covered
against a real deck as well as the synthetic one.

black==23.7.0 (the pinned pre-commit version) leaves both files unchanged.

Shift+Enter inside a PowerPoint paragraph is an `<a:br/>`, and python-pptx
reports it as `\v`. The converter passes `shape.text`, `cell.text`, the notes
text and the chart title straight through, so that vertical tab reaches the
Markdown. It is a C0 control, not Markdown: renderers drop it, so the two lines
run together, and nothing downstream treats it as whitespace either.

Normalize it where the text enters. A shape's own paragraphs already arrive
joined with newlines, so a soft break reads the same way. A table cell keeps its
row: the newline goes into the HTML the table is built from, where it is
ordinary whitespace and collapses to a space.

A slide title is a special case: an ATX heading cannot span lines, so a newline
there would close the heading and leave the rest as body text. Titles now
collapse their line breaks to spaces, which also fixes the multi-paragraph
titles that already produced a stray body line.
…eaks

main consolidated the tests by format (microsoft#2579), so this PR's test moves
from test_module_misc.py to test_pptx.py, before
test_pptx_chart_multi_series_conversion where it stood, and uses that
file's module-level pptx imports.

@kokokoXUY XU (kokokoXUY) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is right and scoped correctly; one heading case needs the same treatment

Short version: I reproduced the reported problem on the base commit and confirmed that
the patch removes the vertical tab on every path it touches, with no change to the
existing PPTX reference vectors. I would merge it, but I would first apply the same
"a heading is one line" rule to the chart title and add a test for that path. Evidence
below.

What the patch does

  • Adds _normalize_soft_line_breaks(text) -> str
    (packages/markitdown/src/markitdown/converters/_pptx_converter.py:34-43), a
    text.replace("\v", "\n"), and applies it at four places: shape text (:120), notes
    text (:158-162), each table cell before html.escape (:330) and the chart title
    (:352).
  • The slide title additionally collapses newlines to spaces (:125), replacing the old
    text.lstrip() with text.replace("\n", " ").strip().
  • New test test_pptx_soft_line_breaks_are_not_vertical_tabs
    (packages/markitdown/tests/test_pptx.py, in the conversion-regressions section),
    which builds a deck with a soft break in a title, a text box, a table header cell, a
    table body cell and the notes.

What I verified locally

Trees: base 4cc9fa1 and head d253f5d, as two detached worktrees of a local clone.
python-pptx 1.0.2. Every conversion went through the
public API, MarkItDown().convert(path), with PYTHONPATH=<tree>\packages\markitdown\src
so that the tree under test is the one imported (the probe prints
markitdown.__file__ to prove it).

  • The PR's own scenario (title, text box, 2x2 table, notes, each with a soft break):
    base output contains five vertical tabs
    ('\n# TITLE ONE\x0bTITLE TWO\nADDRESS LINE ONE\x0bADDRESS LINE TWO\n| HEADER A\x0bHEADER B | H2 |\n| --- | --- |\n| CELL LINE ONE\x0bCELL LINE TWO | C2 |\n\n### Notes:\nNOTE LINE ONE\x0bNOTE LINE TWO');
    head output contains none and reads # TITLE ONE TITLE TWO,
    ADDRESS LINE ONE + newline + ADDRESS LINE TWO,
    | HEADER A HEADER B | H2 |, | CELL LINE ONE CELL LINE TWO | C2 |, and
    NOTE LINE ONE + newline + NOTE LINE TWO. The table row really does stay one row,
    as the description claims.
  • Multi-paragraph title (the second case the description mentions): base emits
    # FIRST PARA + newline + SECOND PARA, so the heading ends early; head emits
    # FIRST PARA SECOND PARA.
  • A soft break in a body placeholder, in a text box inside a group shape, two
    consecutive breaks (A\v\vB -> A blank line B), a leading break and a trailing
    break all normalize as intended; a title with trailing spaces and a title ending in a
    break are unchanged from base.
  • Table cell paths: A\vB -> A B; a cell whose text is only a break becomes empty;
    header and body cells behave the same way.
  • Chart title: base ### Chart: CHART TITLE ONE\x0bCHART TITLE TWO; head
    ### Chart: CHART TITLE ONE + newline + CHART TITLE TWO. See the first item under
    "What I would change".
  • The new test is a real regression test: run against the base converter it fails
    (1 failed, 49 deselected in 1.40s,
    packages\markitdown\tests\test_pptx.py:804: AssertionError, showing base output
    # TITLE ONE + newline + TITLE TWO).
  • packages/markitdown/tests/test_pptx.py: 49 passed on base, 50 passed on head.
  • The six .pptx reference vectors under packages/markitdown/tests/test_files/
    (including test.pptx, pptx_empty_body_run.pptx, pptx_empty_title_run.pptx,
    pptx_notes_empty_run.pptx, pptx_notes_missing_body_frame.pptx,
    test_svg_no_fallback.pptx) produce byte-identical Markdown on both trees; none of
    them contains a vertical tab, so no existing expectation depended on the old output.
  • python-pptx's \v is genuinely the outlier here: python-docx already maps
    <w:br/> (text wrapping) and <w:cr/> to \n (docx/oxml/text/run.py:182-206 in
    python-docx 1.2.0), so putting the fix in the PPTX converter, rather than normalizing
    document text centrally, matches how the other formats arrive.

What I would change before merging

  1. Chart title. After the patch the chart title is normalized but not collapsed, so a
    soft break inside it terminates the ATX heading:

    ### Chart: CHART TITLE ONE
    CHART TITLE TWO
    

    The second line is no longer part of the ### Chart: ... line, which is exactly the
    argument the patch uses for the slide title ("an ATX heading cannot span lines").
    The old output (### Chart: CHART TITLE ONE\x0bCHART TITLE TWO) kept it on one ugly
    line; the new output splits it. A text.replace("\n", " ") on that path, matching
    the slide title, would remove the inconsistency; if the split is deliberate it
    deserves a comment, because nothing in the patch says so.

  2. Test coverage for the two paths above. The new test covers title, text box, table
    cells and notes, but not the chart title (the path with the problem in item 1) and
    not the multi-paragraph slide title that the description says it also repairs
    (only the \v shape of it is covered). Adding a soft break to a chart title and to
    a two-paragraph title would pin both behaviours down.

Notes, not blockers

  • What a soft break becomes. In Markdown a bare newline is a soft break: a CommonMark
    renderer keeps it in the source but renders it as a space, so a strict renderer still
    shows ADDRESS LINE ONE ADDRESS LINE TWO rather than two lines. A rendered line break
    would need two trailing spaces or <br>, and MarkItDown._convert
    (packages/markitdown/src/markitdown/_markitdown.py:682-685) currently rstrip()s
    every line and collapses runs of blank lines, so hard breaks cannot survive today.
    PR #2559 ("preserve Markdown hard line breaks", open) is the change that would allow
    them, and its description already notes that it does not overlap this PR. Using \n
    is consistent with how pptx paragraphs arrive and is the right choice for text
    consumers, but it is worth one sentence in the PR description (or the docs) recording
    that the result is a soft break, not a rendered <br>.
  • Out of scope, but visible on the table path this patch touches: a literal | in a
    cell is not escaped, so a cell reading PIPE | INSIDE produces an extra column
    (| | PIPE | INSIDE |); and a cell whose text is <tag>&amp; is emitted as
    <tag>&amp;, so the angle-bracket text goes out unescaped and the literal text
    &amp; stays an entity that renders as &. Both predate this patch (they are
    reproducible on the base commit) and neither is made worse by it.

How this was produced

Scripts: _r234_md2513_probe.py (builds nine fixtures with
python-pptx and converts each one, printing the raw repr), _r234_md2513_vectors.py
(converts every .pptx reference vector). Outputs: _r234_md2513_probe_base.txt,
_r234_md2513_probe_head.txt, _r234_md2513_vectors_base.txt,
_r234_md2513_vectors_head.txt. Fixtures live in a scratch directory beside them.
The red-test run copied the head test file into the base tree, ran it, and restored the
tree (the base worktree is clean again).

Verdict

The core change is correct, minimal and verified end to end at d253f5d; I would merge
it after collapsing the chart-title line the same way as the slide title and adding the
two missing test cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants